SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
4 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
6.7 KB · 59 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { estimateRoleUnit } from "@/lib/estimate";5import { marketIndex, nearbyListings } from "@/lib/queries";6import { dbAvailable } from "@/lib/db";7import { fmtPrice, fmtPct, fmtInt, fmtDate } from "@/lib/format";8import { Notice, Stat } from "@/components/ui";9import { EstimateBreakdown } from "@/components/estimate-breakdown";10import { IndexChart } from "@/components/price-chart";11import { MapView } from "@/components/map-view";12import { ListingCardView } from "@/components/listing-card";13import { MortgageWidget } from "@/components/mortgage-widget";14export const dynamic = "force-dynamic";15type Props = { params: Promise<{ id: string }> };16export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params; const e = dbAvailable() ? estimateRoleUnit(decodeURIComponent(id)) : null; return { title: e ? `Estimation — ${e.subject.label}, ${e.subject.city}` : "Estimation", robots: { index: false } }; }17export default async function EstimationPage({ params }: Props) {18  const { id } = await params; if (!dbAvailable()) notFound();19  const e = estimateRoleUnit(decodeURIComponent(id)); if (!e) notFound();20  const r = e.result; const u = e.roleUnit!;21  const idx = marketIndex(e.indexType);22  const near = nearbyListings(e.subject.lat, e.subject.lng, "sale", null, 4);23  return (24    <article className="mx-auto max-w-6xl px-4 py-6">25      <nav className="text-xs text-ink-3 mb-2"><Link href="/estimer" className="hover:underline">Estimer</Link> › {u.city}</nav>26      <div className="grid lg:grid-cols-[1fr_360px] gap-6">27        <div>28          <p className="tv-label">Valeur estimée · {fmtDate(new Date().toISOString())}</p>29          <p className="tv-price text-4xl md:text-5xl mt-1">{fmtPrice(r.estimate)}</p>30          <p className="text-ink-2">{fmtPrice(r.low)} – {fmtPrice(r.high)} · confiance <strong>{r.confidenceLevel}</strong> ({r.confidencePct} %)</p>31          <h1 className="mt-3 text-xl md:text-2xl font-bold tracking-tight">{e.subject.label}</h1>32          <p className="text-ink-2">{u.city} · {u.cubf_label ?? u.type_prop}{u.year_built ? ` · construite en ${u.year_built}` : ""}{u.floor_area_m2 ? ` · ${fmtInt(u.floor_area_m2)} m² habitables` : ""}{u.land_area_m2 ? ` · terrain ${fmtInt(u.land_area_m2)} m²` : ""}{u.n_units && u.n_units > 1 ? ` · ${u.n_units} logements` : ""}</p>33          <div className="mt-5"><EstimateBreakdown e={e} /></div>34          <div className="mt-5 grid sm:grid-cols-3 gap-3">35            <Stat label="Valeur au rôle" value={fmtPrice(u.role_value)} sub={u.role_market_date ? `conditions du marché au ${fmtDate(u.role_market_date)}` : undefined} />36            <Stat label="Estimation / rôle" value={u.role_value ? fmtPct(r.estimate / u.role_value - 1) : "—"} sub="écart avec la valeur municipale" />37            <Stat label="Hédonique P10–P90" value={u.p10 && u.p90 ? `${fmtPrice(u.p10, { compact: true })} – ${fmtPrice(u.p90, { compact: true })}` : "—"} sub={u.est ? `point ${fmtPrice(u.est)}` : "non disponible"} />38          </div>39          <section className="mt-8">40            <h2 className="font-bold text-lg mb-2">Comparables ajustés ({r.nCompsUsed})</h2>41            <div className="overflow-x-auto tv-card"><table className="w-full text-sm"><thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Vente</th><th className="p-2 font-medium">Date</th><th className="p-2 font-medium text-right">Prix</th><th className="p-2 font-medium text-right">Ajustements</th><th className="p-2 font-medium text-right">Ajusté</th><th className="p-2 font-medium text-right">Dist.</th></tr></thead>42              <tbody>{r.comps.map((c) => <tr key={c.id} className="border-t border-line"><td className="p-2"><span className="font-medium">{c.street ?? "Adresse non publiée"}</span><span className="block text-xs text-ink-3">{c.city} · {c.propertyType ?? "n/d"}{c.floorArea ? ` · ${Math.round(c.floorArea)} m²` : ""}{c.yearBuilt ? ` · ${c.yearBuilt}` : ""}</span></td><td className="p-2 text-ink-2">{fmtDate(c.date)}</td><td className="p-2 text-right tabular-nums">{fmtPrice(c.amount)}</td><td className="p-2 text-right tabular-nums text-xs text-ink-2">marché {c.adjTime >= 0 ? "+" : ""}{fmtPrice(c.adjTime)}<br />superficie {c.adjArea >= 0 ? "+" : ""}{fmtPrice(c.adjArea)}<br />âge {c.adjAge >= 0 ? "+" : ""}{fmtPrice(c.adjAge)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(c.adjustedPrice)}</td><td className="p-2 text-right text-ink-2">{c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`}</td></tr>)}</tbody></table></div>43          </section>44          {idx.length > 1 && <section className="mt-8"><h2 className="font-bold text-lg mb-2">Évolution du marché ({e.indexType})</h2><IndexChart points={idx} /><p className="text-xs text-ink-3 mt-1">Indice mensuel du prix au m² par type de propriété, base 100 = dernier mois ({idx[idx.length - 1].month}). Source : base de transactions Toveli.</p></section>}45          <section className="mt-8"><h2 className="font-bold text-lg mb-3">Méthode, données et limites</h2>46            <div className="tv-card p-4 text-sm space-y-2"><p><strong>{e.method.name}</strong> · {e.method.version}</p><p className="text-ink-2"><strong>Entrées :</strong> {e.method.inputs.join(" ; ")}.</p><ul className="list-disc pl-5 text-ink-2 space-y-0.5">{e.method.limits.map((x) => <li key={x}>{x}</li>)}</ul><Notice>{e.method.disclaimer}</Notice></div>47          </section>48          <section className="mt-8"><h2 className="font-bold text-lg mb-3">Financer cette valeur</h2><MortgageWidget price={r.estimate} city={u.city} compact /></section>49        </div>50        <aside className="space-y-4">51          <div className="h-56 rounded-card overflow-hidden border border-line"><MapView points={[{ uid: u.id, lat: e.subject.lat, lng: e.subject.lng, price: r.estimate }]} center={[e.subject.lat, e.subject.lng]} zoom={15} /></div>52          <div className="tv-card p-4"><p className="tv-label">Rôle d'évaluation</p><dl className="mt-2 text-sm space-y-1">{[["Terrain", u.role_land], ["Bâtiment", u.role_building], ["Total", u.role_value]].map(([k, v]) => <div key={String(k)} className="flex justify-between"><dt className="text-ink-3">{k}</dt><dd className="tabular-nums">{fmtPrice(v as number)}</dd></div>)}</dl><p className="mt-2 text-xs text-ink-3">MAMH · Données Québec (licence ouverte). Unité {u.id.slice(0, 12)}…</p></div>53          {near.length > 0 && <div><p className="tv-label mb-2">À vendre à proximité</p><div className="space-y-2">{near.map((c) => <ListingCardView key={c.uid} c={c} compact />)}</div></div>}54        </aside>55      </div>56    </article>57  );58}59